logo头像

学如逆水行舟

iOS研习记——记MJExtension多线程Crash的解决历程

[iOS研习记]——记MJExtension多线程Crash的解决历程

难缠的Crash问题

本篇博客的起源是由于收集到线上用户产生的一些难缠的Crash问题,通过堆栈信息观察,Crash的堆栈信息主要有两类:

一类如下:

1
2
3
4
5
6
1   MJExtensionDemo                     0x000000010903a5e0 main + 0,
2 MJExtension 0x000000010923f00d +[NSObject(MJClass) mj_setupBlockReturnValue:key:] + 333,
3 MJExtension 0x000000010923ec86 +[NSObject(MJClass) mj_setupIgnoredPropertyNames:] + 70,
4 MJExtensionTests 0x00000001095ebe1b -[MJExtensionTests testNestedModelArray] + 1467,
5 CoreFoundation 0x00007fff204272fc __invoking___ + 140,
6 CoreFoundation 0x00007fff204247b6 -[NSInvocation invoke] + 303,

一类如下:

1
2
3
4
5
6
7
1   MJExtensionDemo                     0x000000010729e5e0 main + 0,
2 MJExtension 0x00000001074a3255 +[NSObject(MJClass) mj_totalObjectsWithSelector:key:] + 453,
3 MJExtension 0x00000001074a2ccf +[NSObject(MJClass) mj_totalIgnoredPropertyNames] + 47,
4 MJExtension 0x00000001074a3dcb -[NSObject(MJKeyValue) mj_setKeyValues:context:] + 443,
5 MJExtension 0x00000001074a3bdf -[NSObject(MJKeyValue) mj_setKeyValues:] + 79,
6 MJExtension 0x00000001074a6536 +[NSObject(MJKeyValue) mj_objectWithKeyValues:context:] + 710,
7 MJExtension 0x00000001074a623f +[NSObject(MJKeyValue) mj_objectWithKeyValues:] + 79,

此时使用的MJExtension版本为3.2.4,虽然堆栈信息比较清楚,然而其最后的调用都是在MJExtension内部,且发生此Crash的几率非常小(约为万分之几),定位和解决此Crash并不容易。

通过分析,发现此Crash有如下特点:
  • 调用栈中最终定位到的函数都在MJExtension进行JSON转对象或模型setup配置时。
  • 只有在多线程使用MJExtension方法时会出现此Crash。
  • 是App在某次版本更新后才开始出现此类Crash。

通过分析上面的特点,可以推理出:

  1. 问题一定出在mj_objectWithKeyValues方法或mj_setup相关方法中。
  2. 此问题一定是由于业务的某种使用方式或场景的改变触发的。
  3. 一定和多线程相关,推测和锁可能相关。

问题的定位与复现

对于iOS端开发,定位和解决Crash毕竟两个流程,首先是根据线索来分析和定位问题,得到一个大概的猜想,之后按照自己的猜想去提供外部条件,来尝试复现问题,如果问题能够成功复现并复原与线程问题相似的堆栈现场,则基本完成了90%的工作,剩下的10%才是修复此问题。

首先,根据前面我们对问题的分析和推理,可以从mj\_objectWithKeyValues和mj\_setup方法进行切入,通过对MJExtension代码的Review,可以发现这些方法中有一个宏使用的非常频繁,后来也证明问题确实出在这个宏的定义上:

这几个宏的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#ifndef MJ_LOCK
#define MJ_LOCK(lock) dispatch_semaphore_wait(lock, DISPATCH_TIME_FOREVER);
#endif

#ifndef MJ_UNLOCK
#define MJ_UNLOCK(lock) dispatch_semaphore_signal(lock);
#endif

// 信号量
#define MJExtensionSemaphoreCreate \
static dispatch_semaphore_t signalSemaphore; \
static dispatch_once_t onceTokenSemaphore; \
dispatch_once(&onceTokenSemaphore, ^{ \
signalSemaphore = dispatch_semaphore_create(1); \
});

#define MJExtensionSemaphoreWait MJ_LOCK(signalSemaphore)
#define MJExtensionSemaphoreSignal MJ_UNLOCK(signalSemaphore)

可以看到,这个宏的最终使用方式是通过信号量来实现锁逻辑。问题出在static和宏定义本身,宏定义是做简单的替换,因此在实际使用时,dispatch_semaphore_t信号量变量被定义成了局部静态变量,局部静态 变量有一个特点:其被创建后会被放入全局数据区,但是其受函数作用域的控制,即创建后不会销毁,函数内永远可用,但是对函数外来说是隐藏的。如果在不同的函数中使用了相同名称的静态局部变量,真正放入全局数据区的实际上是多个不同的变量。

我们可以通过查看C文件编译后的.o可执行文件来验证局部静态变量的这一特点:

测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
> #include <stdio.h>
>
> int main(int argc, const char * argv[]) {
> static char *string = "hello";
> return 0;
> }
>
> void func1() {
> static char *string = "world";
> }
>

查看.o文件的布局信息如下:

可以看到,实际存储的静态变量名都被加上了函数前缀。

到此,我们基本将问题定位到了,当多线程对MJExtension中的多个不同的函数进行调用时,如果这些函数中都有此加锁逻辑,实际上这个锁逻辑并没有生效,会产生多线程数据读写Crash。要复现这个场景就非常简单了:

1
2
3
4
5
6
7
8
9
10
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
for (int i = 0; i < 1000; i++) {
MJStatusResult *result = [MJStatusResult mj_objectWithKeyValues:dict];
}
});
for (int i = 0; i < 1000; i++) {
[MJStatus mj_setupIgnoredPropertyNames:^NSArray *{
return @[@"name"];
}];
}

通过场景复现,基本可以定位此问题原因。

几个疑问的解答

1. 产生此Crash的核心原理

多线程锁失效导致的多线程读写异常。

2.为何版本更新后会出现

需要从业务使用上来分析,之前的版本类似mj_setup相关方法的调用会放入类的+load方法中,这个在main函数调用之前,所有类的解析配置都已完成,基本不会出现多线程问题,新版本做了冷启动的优化,将mj_setup相关方法放入了+(void)initialize方法中,使得多线程问题被触发的概率大大增加了。

MJExtension后续版本

截止到本篇博客编写时间,MJExtension最新版本3.2.5已经处理了这个锁问题的Bug,其修复方式是将static修改为了extern,使这个信号量变量被声明为了一个全局变量,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#ifndef MJ_LOCK
#define MJ_LOCK(lock) dispatch_semaphore_wait(lock, DISPATCH_TIME_FOREVER);
#endif

#ifndef MJ_UNLOCK
#define MJ_UNLOCK(lock) dispatch_semaphore_signal(lock);
#endif

// 信号量
#define MJExtensionSemaphoreCreate \
extern dispatch_semaphore_t mje_signalSemaphore; \
extern dispatch_once_t mje_onceTokenSemaphore; \
dispatch_once(&mje_onceTokenSemaphore, ^{ \
mje_signalSemaphore = dispatch_semaphore_create(1); \
});


// .m文件中
dispatch_semaphore_t mje_signalSemaphore;
dispatch_once_t mje_onceTokenSemaphore;

修改后的代码保证了锁的唯一性。

建议

使用MJExtension库时,如果需要进行解析配置,优先使用复写相关配置+方法来实现,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 不建议的使用方式
+ (void)initialize {
[self mj_setupObjectClassInArray:^NSDictionary *{
return @{
@"nicknames" : MJStatus.class
};
}];
}

// 建议的使用方式
+ (NSDictionary *)mj_objectClassInArray {
return @{
@"nicknames" : @"MJStatus"
};
}

并且,在配置类型时,尽量使用NSString而不要使用Class,避免类过早的被加载。